'use strict'
console.clear()
// This is a prime example of what starts out as a simple project
// and snowballs way beyond its intended size. It's a little clunky
// reading/working on this single file, but here it is anyways :)

const IS_MOBILE = window.innerWidth <= 640
const IS_DESKTOP = window.innerWidth > 800
const IS_HEADER = IS_DESKTOP && window.innerHeight < 300
// Detect high end devices. This will be a moving target.
const IS_HIGH_END_DEVICE = (() => {
  const hwConcurrency = navigator.hardwareConcurrency
  if (!hwConcurrency) {
    return false
  }
  // Large screens indicate a full size computer, which often have hyper threading these days.
  // So a quad core desktop machine has 8 cores. We'll place a higher min threshold there.
  const minCount = window.innerWidth <= 1024 ? 4 : 8
  return hwConcurrency >= minCount
})()
// Prevent canvases from getting too large on ridiculous screen sizes.
// 8K - can restrict this if needed
const MAX_WIDTH = 7680
const MAX_HEIGHT = 4320
const GRAVITY = 0.9 // Acceleration in px/s
let simSpeed = 1

function getDefaultScaleFactor() {
  if (IS_MOBILE) return 0.9
  if (IS_HEADER) return 0.75
  return 1
}

// Width/height values that take scale into account.
// USE THESE FOR DRAWING POSITIONS
let stageW, stageH

// All quality globals will be overwritten and updated via `configDidUpdate`.
let quality = 1
let isLowQuality = false
let isNormalQuality = true
let isHighQuality = false

const QUALITY_LOW = 1
const QUALITY_NORMAL = 2
const QUALITY_HIGH = 3

const SKY_LIGHT_NONE = 0
const SKY_LIGHT_DIM = 1
const SKY_LIGHT_NORMAL = 2

const COLOR = {
  Red: '#ff0043',
  Green: '#14fc56',
  Blue: '#1e7fff',
  Purple: '#e60aff',
  Gold: '#ffbf36',
  White: '#ffffff'
}

// Special invisible color (not rendered, and therefore not in COLOR map)
const INVISIBLE = '_INVISIBLE_'

const PI_2 = Math.PI * 2
const PI_HALF = Math.PI * 0.5

// Stage.disableHighDPI = true;
const trailsStage = new Stage('trails-canvas')
const mainStage = new Stage('main-canvas')
const stages = [trailsStage, mainStage]

// Fullscreen helpers, using Fscreen for prefixes.
function fullscreenEnabled() {
  return fscreen.fullscreenEnabled
}

// Note that fullscreen state is synced to store, and the store should be the source
// of truth for whether the app is in fullscreen mode or not.
function isFullscreen() {
  return !!fscreen.fullscreenElement
}

// Attempt to toggle fullscreen mode.
function toggleFullscreen() {
  if (fullscreenEnabled()) {
    if (isFullscreen()) {
      fscreen.exitFullscreen()
    } else {
      fscreen.requestFullscreen(document.documentElement)
    }
  }
}

// Sync fullscreen changes with store. An event listener is necessary because the user can
// toggle fullscreen mode directly through the browser, and we want to react to that.
fscreen.addEventListener('fullscreenchange', () => {
  store.setState({ fullscreen: isFullscreen() })
})

// Simple state container; the source of truth.
const store = {
  _listeners: new Set(),
  _dispatch(prevState) {
    this._listeners.forEach((listener) => listener(this.state, prevState))
  },

  state: {
    // will be unpaused in init()
    paused: true,
    soundEnabled: true,
    menuOpen: false,
    openHelpTopic: null,
    fullscreen: isFullscreen(),
    // Note that config values used for <select>s must be strings, unless manually converting values to strings
    // at render time, and parsing on change.
    config: {
      quality: String(IS_HIGH_END_DEVICE ? QUALITY_HIGH : QUALITY_NORMAL), // will be mirrored to a global variable named `quality` in `configDidUpdate`, for perf.
      shell: 'Random',
      size: IS_DESKTOP
        ? '3' // Desktop default
        : IS_HEADER
        ? '1.2' // Profile header default (doesn't need to be an int)
        : '2', // Mobile default
      autoLaunch: false,
      finale: false,
      skyLighting: SKY_LIGHT_NORMAL + '',
      hideControls: IS_HEADER,
      longExposure: false,
      scaleFactor: getDefaultScaleFactor()
    }
  },

  setState(nextState) {
    const prevState = this.state
    this.state = Object.assign({}, this.state, nextState)
    this._dispatch(prevState)
    this.persist()
  },

  subscribe(listener) {
    this._listeners.add(listener)
    return () => this._listeners.remove(listener)
  },

  // Load / persist select state to localStorage
  // Mutates state because `store.load()` should only be called once immediately after store is created, before any subscriptions.
  load() {
    const serializedData = localStorage.getItem('cm_fireworks_data')
    if (serializedData) {
      const { schemaVersion, data } = JSON.parse(serializedData)

      const config = this.state.config
      switch (schemaVersion) {
        case '1.1':
          config.quality = data.quality
          config.size = data.size
          config.skyLighting = data.skyLighting
          break
        case '1.2':
          config.quality = data.quality
          config.size = data.size
          config.skyLighting = data.skyLighting
          config.scaleFactor = data.scaleFactor
          break
        default:
          throw new Error('version switch should be exhaustive')
      }
      console.log(`Loaded config (schema version ${schemaVersion})`)
    }
    // Deprecated data format. Checked with care (it's not namespaced).
    else if (localStorage.getItem('schemaVersion') === '1') {
      let size
      // Attempt to parse data, ignoring if there is an error.
      try {
        const sizeRaw = localStorage.getItem('configSize')
        size = typeof sizeRaw === 'string' && JSON.parse(sizeRaw)
      } catch (e) {
        console.log('Recovered from error parsing saved config:')
        console.error(e)
        return
      }
      // Only restore validated values
      const sizeInt = parseInt(size, 10)
      if (sizeInt >= 0 && sizeInt <= 4) {
        this.state.config.size = String(sizeInt)
      }
    }
  },

  persist() {
    const config = this.state.config
    localStorage.setItem(
      'cm_fireworks_data',
      JSON.stringify({
        schemaVersion: '1.2',
        data: {
          quality: config.quality,
          size: config.size,
          skyLighting: config.skyLighting,
          scaleFactor: config.scaleFactor
        }
      })
    )
  }
}

if (!IS_HEADER) {
  store.load()
}

// Actions
// ---------

function togglePause(toggle) {
  const paused = store.state.paused
  let newValue
  if (typeof toggle === 'boolean') {
    newValue = toggle
  } else {
    newValue = !paused
  }

  if (paused !== newValue) {
    store.setState({ paused: newValue })
  }
}

function toggleSound(toggle) {
  if (typeof toggle === 'boolean') {
    store.setState({ soundEnabled: toggle })
  } else {
    store.setState({ soundEnabled: !store.state.soundEnabled })
  }
}

function toggleMenu(toggle) {
  if (typeof toggle === 'boolean') {
    store.setState({ menuOpen: toggle })
  } else {
    store.setState({ menuOpen: !store.state.menuOpen })
  }
}

function updateConfig(nextConfig) {
  nextConfig = nextConfig || getConfigFromDOM()
  store.setState({
    config: Object.assign({}, store.state.config, nextConfig)
  })

  configDidUpdate()
}

// Map config to various properties & apply side effects
function configDidUpdate() {
  const config = store.state.config

  quality = qualitySelector()
  isLowQuality = quality === QUALITY_LOW
  isNormalQuality = quality === QUALITY_NORMAL
  isHighQuality = quality === QUALITY_HIGH

  if (skyLightingSelector() === SKY_LIGHT_NONE) {
    appNodes.canvasContainer.style.backgroundColor = '#000'
  }

  Spark.drawWidth = quality === QUALITY_HIGH ? 0.75 : 1
}

// Selectors
// -----------

const isRunning = (state = store.state) => !state.paused && !state.menuOpen
// Whether user has enabled sound.
const soundEnabledSelector = (state = store.state) => state.soundEnabled
// Whether any sounds are allowed, taking into account multiple factors.
const canPlaySoundSelector = (state = store.state) =>
  isRunning(state) && soundEnabledSelector(state)
// Convert quality to number.
const qualitySelector = () => +store.state.config.quality
const shellNameSelector = () => store.state.config.shell
// Convert shell size to number.
const shellSizeSelector = () => +store.state.config.size
const finaleSelector = () => store.state.config.finale
const skyLightingSelector = () => +store.state.config.skyLighting
const scaleFactorSelector = () => store.state.config.scaleFactor

// Help Content
const helpContent = {
  shellType: {
    header: '烟花类型',
    body: '选择将要发射的烟花类型。 选择“随机”是一个不错的选择！'
  },
  shellSize: {
    header: '烟花大小',
    body: '烟花的大小：以真实的烟花炮弹尺寸为模型，较大的炮弹具有更大的爆发力，带有更多的星星，有时效果更复杂。 但是，较大的尺寸还需要更多的处理能力，并且可能会导致延迟。'
  },
  quality: {
    header: '画质',
    body: '整体图形质量。 如果动画运行不流畅，请尝试降低质量。 高质量会大大增加产生的火花数量，并可能导致滞后。'
  },
  skyLighting: {
    header: '天空照明',
    body: '烟花爆炸时照亮背景。 如果背景在屏幕上看起来太亮，请尝试将其设置为“暗淡”或“无”。'
  },
  scaleFactor: {
    header: '规模',
    body: '允许缩放所有烟花的大小，实质上使您靠近或远离。 对于较大的烟花类型，可以稍微减小比例，尤其是在手机或平板电脑上。'
  },
  autoLaunch: {
    header: '自动发射',
    body: '启用此选项即可自动启动烟花序列，取消勾选你就可以手动控制烟花的发射'
  },
  finaleMode: {
    header: '结局模式',
    body: '发射强烈的烟花。 可能会导致延迟。 需要同时启用“自动发射”。'
  },
  hideControls: {
    header: '隐藏控制器',
    body: '隐藏屏幕顶部的半透明控件。 对于屏幕截图很有用，或者只是更无缝的体验。 隐藏后，您仍然可以点击右上角以重新打开此菜单。'
  },
  fullscreen: {
    header: '全屏',
    body: '切换全屏模式。'
  },
  longExposure: {
    header: '打开快门',
    body: '实验效果可以保留长时间的光线，类似于保持相机快门打开。'
  }
}

const nodeKeyToHelpKey = {
  shellTypeLabel: 'shellType',
  shellSizeLabel: 'shellSize',
  qualityLabel: 'quality',
  skyLightingLabel: 'skyLighting',
  scaleFactorLabel: 'scaleFactor',
  autoLaunchLabel: 'autoLaunch',
  finaleModeLabel: 'finaleMode',
  hideControlsLabel: 'hideControls',
  fullscreenLabel: 'fullscreen',
  longExposureLabel: 'longExposure'
}

// Render app UI / keep in sync with state
const appNodes = {
  stageContainer: '.stage-container',
  canvasContainer: '.canvas-container',
  controls: '.controls',
  menu: '.menu',
  menuInnerWrap: '.menu__inner-wrap',
  pauseBtn: '.pause-btn',
  pauseBtnSVG: '.pause-btn use',
  soundBtn: '.sound-btn',
  soundBtnSVG: '.sound-btn use',
  shellType: '.shell-type',
  shellTypeLabel: '.shell-type-label',
  shellSize: '.shell-size',
  shellSizeLabel: '.shell-size-label',
  quality: '.quality-ui',
  qualityLabel: '.quality-ui-label',
  skyLighting: '.sky-lighting',
  skyLightingLabel: '.sky-lighting-label',
  scaleFactor: '.scaleFactor',
  scaleFactorLabel: '.scaleFactor-label',
  autoLaunch: '.auto-launch',
  autoLaunchLabel: '.auto-launch-label',
  finaleModeFormOption: '.form-option--finale-mode',
  finaleMode: '.finale-mode',
  finaleModeLabel: '.finale-mode-label',
  hideControls: '.hide-controls',
  hideControlsLabel: '.hide-controls-label',
  fullscreenFormOption: '.form-option--fullscreen',
  fullscreen: '.fullscreen',
  fullscreenLabel: '.fullscreen-label',
  longExposure: '.long-exposure',
  longExposureLabel: '.long-exposure-label',

  // Help UI
  helpModal: '.help-modal',
  helpModalOverlay: '.help-modal__overlay',
  helpModalHeader: '.help-modal__header',
  helpModalBody: '.help-modal__body',
  helpModalCloseBtn: '.help-modal__close-btn'
}

// Convert appNodes selectors to dom nodes
Object.keys(appNodes).forEach((key) => {
  appNodes[key] = document.querySelector(appNodes[key])
})

// Remove fullscreen control if not supported.
if (!fullscreenEnabled()) {
  appNodes.fullscreenFormOption.classList.add('remove')
}

// First render is called in init()
function renderApp(state) {
  const pauseBtnIcon = `#icon-${state.paused ? 'play' : 'pause'}`
  const soundBtnIcon = `#icon-sound-${soundEnabledSelector() ? 'on' : 'off'}`
  appNodes.pauseBtnSVG.setAttribute('href', pauseBtnIcon)
  appNodes.pauseBtnSVG.setAttribute('xlink:href', pauseBtnIcon)
  appNodes.soundBtnSVG.setAttribute('href', soundBtnIcon)
  appNodes.soundBtnSVG.setAttribute('xlink:href', soundBtnIcon)
  appNodes.controls.classList.toggle(
    'hide',
    state.menuOpen || state.config.hideControls
  )
  appNodes.canvasContainer.classList.toggle('blur', state.menuOpen)
  appNodes.menu.classList.toggle('hide', !state.menuOpen)
  appNodes.finaleModeFormOption.style.opacity = state.config.autoLaunch
    ? 1
    : 0.32

  appNodes.quality.value = state.config.quality
  appNodes.shellType.value = state.config.shell
  appNodes.shellSize.value = state.config.size
  appNodes.autoLaunch.checked = state.config.autoLaunch
  appNodes.finaleMode.checked = state.config.finale
  appNodes.skyLighting.value = state.config.skyLighting
  appNodes.hideControls.checked = state.config.hideControls
  appNodes.fullscreen.checked = state.fullscreen
  appNodes.longExposure.checked = state.config.longExposure
  appNodes.scaleFactor.value = state.config.scaleFactor.toFixed(2)

  appNodes.menuInnerWrap.style.opacity = state.openHelpTopic ? 0.12 : 1
  appNodes.helpModal.classList.toggle('active', !!state.openHelpTopic)
  if (state.openHelpTopic) {
    const { header, body } = helpContent[state.openHelpTopic]
    appNodes.helpModalHeader.textContent = header
    appNodes.helpModalBody.textContent = body
  }
}

store.subscribe(renderApp)

// Perform side effects on state changes
function handleStateChange(state, prevState) {
  const canPlaySound = canPlaySoundSelector(state)
  const canPlaySoundPrev = canPlaySoundSelector(prevState)

  if (canPlaySound !== canPlaySoundPrev) {
    if (canPlaySound) {
      soundManager.resumeAll()
    } else {
      soundManager.pauseAll()
    }
  }
}

store.subscribe(handleStateChange)

function getConfigFromDOM() {
  return {
    quality: appNodes.quality.value,
    shell: appNodes.shellType.value,
    size: appNodes.shellSize.value,
    autoLaunch: appNodes.autoLaunch.checked,
    finale: appNodes.finaleMode.checked,
    skyLighting: appNodes.skyLighting.value,
    longExposure: appNodes.longExposure.checked,
    hideControls: appNodes.hideControls.checked,
    // Store value as number.
    scaleFactor: parseFloat(appNodes.scaleFactor.value)
  }
}

const updateConfigNoEvent = () => updateConfig()
appNodes.quality.addEventListener('input', updateConfigNoEvent)
appNodes.shellType.addEventListener('input', updateConfigNoEvent)
appNodes.shellSize.addEventListener('input', updateConfigNoEvent)
appNodes.autoLaunch.addEventListener('click', () => setTimeout(updateConfig, 0))
appNodes.finaleMode.addEventListener('click', () => setTimeout(updateConfig, 0))
appNodes.skyLighting.addEventListener('input', updateConfigNoEvent)
appNodes.longExposure.addEventListener('click', () =>
  setTimeout(updateConfig, 0)
)
appNodes.hideControls.addEventListener('click', () =>
  setTimeout(updateConfig, 0)
)
appNodes.fullscreen.addEventListener('click', () =>
  setTimeout(toggleFullscreen, 0)
)
// Changing scaleFactor requires triggering resize handling code as well.
appNodes.scaleFactor.addEventListener('input', () => {
  updateConfig()
  handleResize()
})

Object.keys(nodeKeyToHelpKey).forEach((nodeKey) => {
  const helpKey = nodeKeyToHelpKey[nodeKey]
  appNodes[nodeKey].addEventListener('click', () => {
    store.setState({ openHelpTopic: helpKey })
  })
})

appNodes.helpModalCloseBtn.addEventListener('click', () => {
  store.setState({ openHelpTopic: null })
})

appNodes.helpModalOverlay.addEventListener('click', () => {
  store.setState({ openHelpTopic: null })
})

// Constant derivations
const COLOR_NAMES = Object.keys(COLOR)
const COLOR_CODES = COLOR_NAMES.map((colorName) => COLOR[colorName])
// Invisible stars need an indentifier, even through they won't be rendered - physics still apply.
const COLOR_CODES_W_INVIS = [...COLOR_CODES, INVISIBLE]
// Map of color codes to their index in the array. Useful for quickly determining if a color has already been updated in a loop.
const COLOR_CODE_INDEXES = COLOR_CODES_W_INVIS.reduce((obj, code, i) => {
  obj[code] = i
  return obj
}, {})
// Tuples is a map keys by color codes (hex) with values of { r, g, b } tuples (still just objects).
const COLOR_TUPLES = {}
COLOR_CODES.forEach((hex) => {
  COLOR_TUPLES[hex] = {
    r: parseInt(hex.substr(1, 2), 16),
    g: parseInt(hex.substr(3, 2), 16),
    b: parseInt(hex.substr(5, 2), 16)
  }
})

// Get a random color.
function randomColorSimple() {
  return COLOR_CODES[(Math.random() * COLOR_CODES.length) | 0]
}

// Get a random color, with some customization options available.
let lastColor
function randomColor(options) {
  const notSame = options && options.notSame
  const notColor = options && options.notColor
  const limitWhite = options && options.limitWhite
  let color = randomColorSimple()

  // limit the amount of white chosen randomly
  if (limitWhite && color === COLOR.White && Math.random() < 0.6) {
    color = randomColorSimple()
  }

  if (notSame) {
    while (color === lastColor) {
      color = randomColorSimple()
    }
  } else if (notColor) {
    while (color === notColor) {
      color = randomColorSimple()
    }
  }

  lastColor = color
  return color
}

function whiteOrGold() {
  return Math.random() < 0.5 ? COLOR.Gold : COLOR.White
}

// Shell helpers
function makePistilColor(shellColor) {
  return shellColor === COLOR.White || shellColor === COLOR.Gold
    ? randomColor({ notColor: shellColor })
    : whiteOrGold()
}

// Unique shell types
const crysanthemumShell = (size = 1) => {
  const glitter = Math.random() < 0.25
  const singleColor = Math.random() < 0.72
  const color = singleColor
    ? randomColor({ limitWhite: true })
    : [randomColor(), randomColor({ notSame: true })]
  const pistil = singleColor && Math.random() < 0.42
  const pistilColor = pistil && makePistilColor(color)
  const secondColor =
    singleColor && (Math.random() < 0.2 || color === COLOR.White)
      ? pistilColor || randomColor({ notColor: color, limitWhite: true })
      : null
  const streamers = !pistil && color !== COLOR.White && Math.random() < 0.42
  let starDensity = glitter ? 1.1 : 1.25
  if (isLowQuality) starDensity *= 0.8
  if (isHighQuality) starDensity = 1.2
  return {
    shellSize: size,
    spreadSize: 300 + size * 100,
    starLife: 900 + size * 200,
    starDensity,
    color,
    secondColor,
    glitter: glitter ? 'light' : '',
    glitterColor: whiteOrGold(),
    pistil,
    pistilColor,
    streamers
  }
}

const ghostShell = (size = 1) => {
  // Extend crysanthemum shell
  const shell = crysanthemumShell(size)
  // Ghost effect can be fast, so extend star life
  shell.starLife *= 1.5
  // Ensure we always have a single color other than white
  let ghostColor = randomColor({ notColor: COLOR.White })
  // Always use streamers, and sometimes a pistil
  shell.streamers = true
  const pistil = Math.random() < 0.42
  const pistilColor = pistil && makePistilColor(ghostColor)
  // Ghost effect - transition from invisible to chosen color
  shell.color = INVISIBLE
  shell.secondColor = ghostColor
  // We don't want glitter to be spewed by invisible stars, and we don't currently
  // have a way to transition glitter state. So we'll disable it.
  shell.glitter = ''

  return shell
}

const strobeShell = (size = 1) => {
  const color = randomColor({ limitWhite: true })
  return {
    shellSize: size,
    spreadSize: 280 + size * 92,
    starLife: 1100 + size * 200,
    starLifeVariation: 0.4,
    starDensity: 1.1,
    color,
    glitter: 'light',
    glitterColor: COLOR.White,
    strobe: true,
    strobeColor: Math.random() < 0.5 ? COLOR.White : null,
    pistil: Math.random() < 0.5,
    pistilColor: makePistilColor(color)
  }
}

const palmShell = (size = 1) => {
  const color = randomColor()
  const thick = Math.random() < 0.5
  return {
    shellSize: size,
    color,
    spreadSize: 250 + size * 75,
    starDensity: thick ? 0.15 : 0.4,
    starLife: 1800 + size * 200,
    glitter: thick ? 'thick' : 'heavy'
  }
}

const ringShell = (size = 1) => {
  const color = randomColor()
  const pistil = Math.random() < 0.75
  return {
    shellSize: size,
    ring: true,
    color,
    spreadSize: 300 + size * 100,
    starLife: 900 + size * 200,
    starCount: 2.2 * PI_2 * (size + 1),
    pistil,
    pistilColor: makePistilColor(color),
    glitter: !pistil ? 'light' : '',
    glitterColor: color === COLOR.Gold ? COLOR.Gold : COLOR.White,
    streamers: Math.random() < 0.3
  }
  // return Object.assign({}, defaultShell, config);
}

const crossetteShell = (size = 1) => {
  const color = randomColor({ limitWhite: true })
  return {
    shellSize: size,
    spreadSize: 300 + size * 100,
    starLife: 750 + size * 160,
    starLifeVariation: 0.4,
    starDensity: 0.85,
    color,
    crossette: true,
    pistil: Math.random() < 0.5,
    pistilColor: makePistilColor(color)
  }
}

const floralShell = (size = 1) => ({
  shellSize: size,
  spreadSize: 300 + size * 120,
  starDensity: 0.12,
  starLife: 500 + size * 50,
  starLifeVariation: 0.5,
  color:
    Math.random() < 0.65
      ? 'random'
      : Math.random() < 0.15
      ? randomColor()
      : [randomColor(), randomColor({ notSame: true })],
  floral: true
})

const fallingLeavesShell = (size = 1) => ({
  shellSize: size,
  color: INVISIBLE,
  spreadSize: 300 + size * 120,
  starDensity: 0.12,
  starLife: 500 + size * 50,
  starLifeVariation: 0.5,
  glitter: 'medium',
  glitterColor: COLOR.Gold,
  fallingLeaves: true
})

const willowShell = (size = 1) => ({
  shellSize: size,
  spreadSize: 300 + size * 100,
  starDensity: 0.6,
  starLife: 3000 + size * 300,
  glitter: 'willow',
  glitterColor: COLOR.Gold,
  color: INVISIBLE
})

const crackleShell = (size = 1) => {
  // favor gold
  const color = Math.random() < 0.75 ? COLOR.Gold : randomColor()
  return {
    shellSize: size,
    spreadSize: 380 + size * 75,
    starDensity: isLowQuality ? 0.65 : 1,
    starLife: 600 + size * 100,
    starLifeVariation: 0.32,
    glitter: 'light',
    glitterColor: COLOR.Gold,
    color,
    crackle: true,
    pistil: Math.random() < 0.65,
    pistilColor: makePistilColor(color)
  }
}

const horsetailShell = (size = 1) => {
  const color = randomColor()
  return {
    shellSize: size,
    horsetail: true,
    color,
    spreadSize: 250 + size * 38,
    starDensity: 0.9,
    starLife: 2500 + size * 300,
    glitter: 'medium',
    glitterColor: Math.random() < 0.5 ? whiteOrGold() : color,
    // Add strobe effect to white horsetails, to make them more interesting
    strobe: color === COLOR.White
  }
}

function randomShellName() {
  return Math.random() < 0.5
    ? 'Crysanthemum'
    : shellNames[(Math.random() * (shellNames.length - 1) + 1) | 0]
}

function randomShell(size) {
  // Special selection for codepen header.
  if (IS_HEADER) return randomFastShell()(size)
  // Normal operation
  return shellTypes[randomShellName()](size)
}

function shellFromConfig(size) {
  return shellTypes[shellNameSelector()](size)
}

// Get a random shell, not including processing intensive varients
// Note this is only random when "Random" shell is selected in config.
// Also, this does not create the shell, only returns the factory function.
const fastShellBlacklist = ['Falling Leaves', 'Floral', 'Willow']
function randomFastShell() {
  const isRandom = shellNameSelector() === 'Random'
  let shellName = isRandom ? randomShellName() : shellNameSelector()
  if (isRandom) {
    while (fastShellBlacklist.includes(shellName)) {
      shellName = randomShellName()
    }
  }
  return shellTypes[shellName]
}

const shellTypes = {
  Random: randomShell,
  Crackle: crackleShell,
  Crossette: crossetteShell,
  Crysanthemum: crysanthemumShell,
  'Falling Leaves': fallingLeavesShell,
  Floral: floralShell,
  Ghost: ghostShell,
  'Horse Tail': horsetailShell,
  Palm: palmShell,
  Ring: ringShell,
  Strobe: strobeShell,
  Willow: willowShell
}

const shellNames = Object.keys(shellTypes)

function init() {
  // Remove loading state
  document.querySelector('.loading-init').remove()
  appNodes.stageContainer.classList.remove('remove')

  // Populate dropdowns
  function setOptionsForSelect(node, options) {
    node.innerHTML = options.reduce(
      (acc, opt) =>
        (acc += `<option value="${opt.value}">${opt.label}</option>`),
      ''
    )
  }

  // shell type
  let options = ''
  shellNames.forEach(
    (opt) => (options += `<option value="${opt}">${opt}</option>`)
  )
  appNodes.shellType.innerHTML = options
  // shell size
  options = ''
  ;['3"', '4"', '6"', '8"', '12"', '16"'].forEach(
    (opt, i) => (options += `<option value="${i}">${opt}</option>`)
  )
  appNodes.shellSize.innerHTML = options

  setOptionsForSelect(appNodes.quality, [
    { label: '低', value: QUALITY_LOW },
    { label: '正常', value: QUALITY_NORMAL },
    { label: '高', value: QUALITY_HIGH }
  ])

  setOptionsForSelect(appNodes.skyLighting, [
    { label: '无', value: SKY_LIGHT_NONE },
    { label: '暗淡', value: SKY_LIGHT_DIM },
    { label: '正常', value: SKY_LIGHT_NORMAL }
  ])

  // 0.9 is mobile default
  setOptionsForSelect(
    appNodes.scaleFactor,
    [0.5, 0.62, 0.75, 0.9, 1.0, 1.5, 2.0].map((value) => ({
      value: value.toFixed(2),
      label: `${value * 100}%`
    }))
  )

  // Begin simulation
  togglePause(false)

  // initial render
  renderApp(store.state)

  // Apply initial config
  configDidUpdate()
}

function fitShellPositionInBoundsH(position) {
  const edge = 0.18
  return (1 - edge * 2) * position + edge
}

function fitShellPositionInBoundsV(position) {
  return position * 0.75
}

function getRandomShellPositionH() {
  return fitShellPositionInBoundsH(Math.random())
}

function getRandomShellPositionV() {
  return fitShellPositionInBoundsV(Math.random())
}

function getRandomShellSize() {
  const baseSize = shellSizeSelector()
  const maxVariance = Math.min(2.5, baseSize)
  const variance = Math.random() * maxVariance
  const size = baseSize - variance
  const height = maxVariance === 0 ? Math.random() : 1 - variance / maxVariance
  const centerOffset = Math.random() * (1 - height * 0.65) * 0.5
  const x = Math.random() < 0.5 ? 0.5 - centerOffset : 0.5 + centerOffset
  return {
    size,
    x: fitShellPositionInBoundsH(x),
    height: fitShellPositionInBoundsV(height)
  }
}

// Launches a shell from a user pointer event, based on state.config
function launchShellFromConfig(event) {
  const shell = new Shell(shellFromConfig(shellSizeSelector()))
  const w = mainStage.width
  const h = mainStage.height

  shell.launch(
    event ? event.x / w : getRandomShellPositionH(),
    event ? 1 - event.y / h : getRandomShellPositionV()
  )
}

// Sequences
// -----------

function seqRandomShell() {
  const size = getRandomShellSize()
  const shell = new Shell(shellFromConfig(size.size))
  shell.launch(size.x, size.height)

  let extraDelay = shell.starLife
  if (shell.fallingLeaves) {
    extraDelay = 4600
  }

  return 900 + Math.random() * 600 + extraDelay
}

function seqRandomFastShell() {
  const shellType = randomFastShell()
  const size = getRandomShellSize()
  const shell = new Shell(shellType(size.size))
  shell.launch(size.x, size.height)

  let extraDelay = shell.starLife

  return 900 + Math.random() * 600 + extraDelay
}

function seqTwoRandom() {
  const size1 = getRandomShellSize()
  const size2 = getRandomShellSize()
  const shell1 = new Shell(shellFromConfig(size1.size))
  const shell2 = new Shell(shellFromConfig(size2.size))
  const leftOffset = Math.random() * 0.2 - 0.1
  const rightOffset = Math.random() * 0.2 - 0.1
  shell1.launch(0.3 + leftOffset, size1.height)
  setTimeout(() => {
    shell2.launch(0.7 + rightOffset, size2.height)
  }, 100)

  let extraDelay = Math.max(shell1.starLife, shell2.starLife)
  if (shell1.fallingLeaves || shell2.fallingLeaves) {
    extraDelay = 4600
  }

  return 900 + Math.random() * 600 + extraDelay
}

function seqTriple() {
  const shellType = randomFastShell()
  const baseSize = shellSizeSelector()
  const smallSize = Math.max(0, baseSize - 1.25)

  const offset = Math.random() * 0.08 - 0.04
  const shell1 = new Shell(shellType(baseSize))
  shell1.launch(0.5 + offset, 0.7)

  const leftDelay = 1000 + Math.random() * 400
  const rightDelay = 1000 + Math.random() * 400

  setTimeout(() => {
    const offset = Math.random() * 0.08 - 0.04
    const shell2 = new Shell(shellType(smallSize))
    shell2.launch(0.2 + offset, 0.1)
  }, leftDelay)

  setTimeout(() => {
    const offset = Math.random() * 0.08 - 0.04
    const shell3 = new Shell(shellType(smallSize))
    shell3.launch(0.8 + offset, 0.1)
  }, rightDelay)

  return 4000
}

function seqPyramid() {
  const barrageCountHalf = IS_DESKTOP ? 7 : 4
  const largeSize = shellSizeSelector()
  const smallSize = Math.max(0, largeSize - 3)
  const randomMainShell = Math.random() < 0.78 ? crysanthemumShell : ringShell
  const randomSpecialShell = randomShell

  function launchShell(x, useSpecial) {
    const isRandom = shellNameSelector() === 'Random'
    let shellType = isRandom
      ? useSpecial
        ? randomSpecialShell
        : randomMainShell
      : shellTypes[shellNameSelector()]
    const shell = new Shell(shellType(useSpecial ? largeSize : smallSize))
    const height = x <= 0.5 ? x / 0.5 : (1 - x) / 0.5
    shell.launch(x, useSpecial ? 0.75 : height * 0.42)
  }

  let count = 0
  let delay = 0
  while (count <= barrageCountHalf) {
    if (count === barrageCountHalf) {
      setTimeout(() => {
        launchShell(0.5, true)
      }, delay)
    } else {
      const offset = (count / barrageCountHalf) * 0.5
      const delayOffset = Math.random() * 30 + 30
      setTimeout(() => {
        launchShell(offset, false)
      }, delay)
      setTimeout(() => {
        launchShell(1 - offset, false)
      }, delay + delayOffset)
    }

    count++
    delay += 200
  }

  return 3400 + barrageCountHalf * 250
}

function seqSmallBarrage() {
  seqSmallBarrage.lastCalled = Date.now()
  const barrageCount = IS_DESKTOP ? 11 : 5
  const specialIndex = IS_DESKTOP ? 3 : 1
  const shellSize = Math.max(0, shellSizeSelector() - 2)
  const randomMainShell = Math.random() < 0.78 ? crysanthemumShell : ringShell
  const randomSpecialShell = randomFastShell()

  // (cos(x*5π+0.5π)+1)/2 is a custom wave bounded by 0 and 1 used to set varying launch heights
  function launchShell(x, useSpecial) {
    const isRandom = shellNameSelector() === 'Random'
    let shellType = isRandom
      ? useSpecial
        ? randomSpecialShell
        : randomMainShell
      : shellTypes[shellNameSelector()]
    const shell = new Shell(shellType(shellSize))
    const height = (Math.cos(x * 5 * Math.PI + PI_HALF) + 1) / 2
    shell.launch(x, height * 0.75)
  }

  let count = 0
  let delay = 0
  while (count < barrageCount) {
    if (count === 0) {
      launchShell(0.5, false)
      count += 1
    } else {
      const offset = (count + 1) / barrageCount / 2
      const delayOffset = Math.random() * 30 + 30
      const useSpecial = count === specialIndex
      setTimeout(() => {
        launchShell(0.5 + offset, useSpecial)
      }, delay)
      setTimeout(() => {
        launchShell(0.5 - offset, useSpecial)
      }, delay + delayOffset)
      count += 2
    }
    delay += 200
  }

  return 3400 + barrageCount * 120
}
seqSmallBarrage.cooldown = 15000
seqSmallBarrage.lastCalled = Date.now()

const sequences = [
  seqRandomShell,
  seqTwoRandom,
  seqTriple,
  seqPyramid,
  seqSmallBarrage
]

let isFirstSeq = true
const finaleCount = 32
let currentFinaleCount = 0
function startSequence() {
  if (isFirstSeq) {
    isFirstSeq = false
    if (IS_HEADER) {
      return seqTwoRandom()
    } else {
      const shell = new Shell(crysanthemumShell(shellSizeSelector()))
      shell.launch(0.5, 0.5)
      return 2400
    }
  }

  if (finaleSelector()) {
    seqRandomFastShell()
    if (currentFinaleCount < finaleCount) {
      currentFinaleCount++
      return 170
    } else {
      currentFinaleCount = 0
      return 6000
    }
  }

  const rand = Math.random()

  if (
    rand < 0.08 &&
    Date.now() - seqSmallBarrage.lastCalled > seqSmallBarrage.cooldown
  ) {
    return seqSmallBarrage()
  }

  if (rand < 0.1) {
    return seqPyramid()
  }

  if (rand < 0.6 && !IS_HEADER) {
    return seqRandomShell()
  } else if (rand < 0.8) {
    return seqTwoRandom()
  } else if (rand < 1) {
    return seqTriple()
  }
}

let activePointerCount = 0
let isUpdatingSpeed = false

function handlePointerStart(event) {
  activePointerCount++
  const btnSize = 50

  if (event.y < btnSize) {
    if (event.x < btnSize) {
      togglePause()
      return
    }
    if (
      event.x > mainStage.width / 2 - btnSize / 2 &&
      event.x < mainStage.width / 2 + btnSize / 2
    ) {
      toggleSound()
      return
    }
    if (event.x > mainStage.width - btnSize) {
      toggleMenu()
      return
    }
  }

  if (!isRunning()) return

  if (updateSpeedFromEvent(event)) {
    isUpdatingSpeed = true
  } else if (event.onCanvas) {
    launchShellFromConfig(event)
  }
}

function handlePointerEnd(event) {
  activePointerCount--
  isUpdatingSpeed = false
}

function handlePointerMove(event) {
  if (!isRunning()) return

  if (isUpdatingSpeed) {
    updateSpeedFromEvent(event)
  }
}

function handleKeydown(event) {
  // P
  if (event.keyCode === 80) {
    togglePause()
  }
  // O
  else if (event.keyCode === 79) {
    toggleMenu()
  }
  // Esc
  else if (event.keyCode === 27) {
    toggleMenu(false)
  }
}

mainStage.addEventListener('pointerstart', handlePointerStart)
mainStage.addEventListener('pointerend', handlePointerEnd)
mainStage.addEventListener('pointermove', handlePointerMove)
window.addEventListener('keydown', handleKeydown)

// Account for window resize and custom scale changes.
function handleResize() {
  const w = window.innerWidth
  const h = window.innerHeight
  // Try to adopt screen size, heeding maximum sizes specified
  const containerW = Math.min(w, MAX_WIDTH)
  // On small screens, use full device height
  const containerH = w <= 420 ? h : Math.min(h, MAX_HEIGHT)
  appNodes.stageContainer.style.width = containerW + 'px'
  appNodes.stageContainer.style.height = containerH + 'px'
  stages.forEach((stage) => stage.resize(containerW, containerH))
  // Account for scale
  const scaleFactor = scaleFactorSelector()
  stageW = containerW / scaleFactor
  stageH = containerH / scaleFactor
}

// Compute initial dimensions
handleResize()

window.addEventListener('resize', handleResize)

// Dynamic globals
let currentFrame = 0
let speedBarOpacity = 0
let autoLaunchTime = 0

function updateSpeedFromEvent(event) {
  if (isUpdatingSpeed || event.y >= mainStage.height - 44) {
    // On phones it's hard to hit the edge pixels in order to set speed at 0 or 1, so some padding is provided to make that easier.
    const edge = 16
    const newSpeed = (event.x - edge) / (mainStage.width - edge * 2)
    simSpeed = Math.min(Math.max(newSpeed, 0), 1)
    // show speed bar after an update
    speedBarOpacity = 1
    // If we updated the speed, return true
    return true
  }
  // Return false if the speed wasn't updated
  return false
}

// Extracted function to keep `update()` optimized
function updateGlobals(timeStep, lag) {
  currentFrame++

  // Always try to fade out speed bar
  if (!isUpdatingSpeed) {
    speedBarOpacity -= lag / 30 // half a second
    if (speedBarOpacity < 0) {
      speedBarOpacity = 0
    }
  }

  // auto launch shells
  if (store.state.config.autoLaunch) {
    autoLaunchTime -= timeStep
    if (autoLaunchTime <= 0) {
      autoLaunchTime = startSequence() * 1.25
    }
  }
}

function update(frameTime, lag) {
  if (!isRunning()) return

  const width = stageW
  const height = stageH
  const timeStep = frameTime * simSpeed
  const speed = simSpeed * lag

  updateGlobals(timeStep, lag)

  const starDrag = 1 - (1 - Star.airDrag) * speed
  const starDragHeavy = 1 - (1 - Star.airDragHeavy) * speed
  const sparkDrag = 1 - (1 - Spark.airDrag) * speed
  const gAcc = (timeStep / 1000) * GRAVITY
  COLOR_CODES_W_INVIS.forEach((color) => {
    // Stars
    const stars = Star.active[color]
    for (let i = stars.length - 1; i >= 0; i = i - 1) {
      const star = stars[i]
      // Only update each star once per frame. Since color can change, it's possible a star could update twice without this, leading to a "jump".
      if (star.updateFrame === currentFrame) {
        continue
      }
      star.updateFrame = currentFrame

      star.life -= timeStep
      if (star.life <= 0) {
        stars.splice(i, 1)
        Star.returnInstance(star)
      } else {
        const burnRate = Math.pow(star.life / star.fullLife, 0.5)
        const burnRateInverse = 1 - burnRate

        star.prevX = star.x
        star.prevY = star.y
        star.x += star.speedX * speed
        star.y += star.speedY * speed
        // Apply air drag if star isn't "heavy". The heavy property is used for the shell comets.
        if (!star.heavy) {
          star.speedX *= starDrag
          star.speedY *= starDrag
        } else {
          star.speedX *= starDragHeavy
          star.speedY *= starDragHeavy
        }
        star.speedY += gAcc

        if (star.spinRadius) {
          star.spinAngle += star.spinSpeed * speed
          star.x += Math.sin(star.spinAngle) * star.spinRadius * speed
          star.y += Math.cos(star.spinAngle) * star.spinRadius * speed
        }

        if (star.sparkFreq) {
          star.sparkTimer -= timeStep
          while (star.sparkTimer < 0) {
            star.sparkTimer +=
              star.sparkFreq * 0.75 + star.sparkFreq * burnRateInverse * 4
            Spark.add(
              star.x,
              star.y,
              star.sparkColor,
              Math.random() * PI_2,
              Math.random() * star.sparkSpeed * burnRate,
              star.sparkLife * 0.8 +
                Math.random() * star.sparkLifeVariation * star.sparkLife
            )
          }
        }

        // Handle star transitions
        if (star.life < star.transitionTime) {
          if (star.secondColor && !star.colorChanged) {
            star.colorChanged = true
            star.color = star.secondColor
            stars.splice(i, 1)
            Star.active[star.secondColor].push(star)
            if (star.secondColor === INVISIBLE) {
              star.sparkFreq = 0
            }
          }

          if (star.strobe) {
            // Strobes in the following pattern: on:off:off:on:off:off in increments of `strobeFreq` ms.
            star.visible = Math.floor(star.life / star.strobeFreq) % 3 === 0
          }
        }
      }
    }

    // Sparks
    const sparks = Spark.active[color]
    for (let i = sparks.length - 1; i >= 0; i = i - 1) {
      const spark = sparks[i]
      spark.life -= timeStep
      if (spark.life <= 0) {
        sparks.splice(i, 1)
        Spark.returnInstance(spark)
      } else {
        spark.prevX = spark.x
        spark.prevY = spark.y
        spark.x += spark.speedX * speed
        spark.y += spark.speedY * speed
        spark.speedX *= sparkDrag
        spark.speedY *= sparkDrag
        spark.speedY += gAcc
      }
    }
  })

  render(speed)
}

function render(speed) {
  const { dpr } = mainStage
  const width = stageW
  const height = stageH
  const trailsCtx = trailsStage.ctx
  const mainCtx = mainStage.ctx

  if (skyLightingSelector() !== SKY_LIGHT_NONE) {
    colorSky(speed)
  }

  // Account for high DPI screens, and custom scale factor.
  const scaleFactor = scaleFactorSelector()
  trailsCtx.scale(dpr * scaleFactor, dpr * scaleFactor)
  mainCtx.scale(dpr * scaleFactor, dpr * scaleFactor)

  trailsCtx.globalCompositeOperation = 'source-over'
  trailsCtx.fillStyle = `rgba(0, 0, 0, ${
    store.state.config.longExposure ? 0.0025 : 0.175 * speed
  })`
  trailsCtx.fillRect(0, 0, width, height)

  mainCtx.clearRect(0, 0, width, height)

  // Draw queued burst flashes
  // These must also be drawn using source-over due to Safari. Seems rendering the gradients using lighten draws large black boxes instead.
  // Thankfully, these burst flashes look pretty much the same either way.
  while (BurstFlash.active.length) {
    const bf = BurstFlash.active.pop()

    const burstGradient = trailsCtx.createRadialGradient(
      bf.x,
      bf.y,
      0,
      bf.x,
      bf.y,
      bf.radius
    )
    burstGradient.addColorStop(0.024, 'rgba(255, 255, 255, 1)')
    burstGradient.addColorStop(0.125, 'rgba(255, 160, 20, 0.2)')
    burstGradient.addColorStop(0.32, 'rgba(255, 140, 20, 0.11)')
    burstGradient.addColorStop(1, 'rgba(255, 120, 20, 0)')
    trailsCtx.fillStyle = burstGradient
    trailsCtx.fillRect(
      bf.x - bf.radius,
      bf.y - bf.radius,
      bf.radius * 2,
      bf.radius * 2
    )

    BurstFlash.returnInstance(bf)
  }

  // Remaining drawing on trails canvas will use 'lighten' blend mode
  trailsCtx.globalCompositeOperation = 'lighten'

  // Draw stars
  trailsCtx.lineWidth = Star.drawWidth
  trailsCtx.lineCap = isLowQuality ? 'square' : 'round'
  mainCtx.strokeStyle = '#fff'
  mainCtx.lineWidth = 1
  mainCtx.beginPath()
  COLOR_CODES.forEach((color) => {
    const stars = Star.active[color]
    trailsCtx.strokeStyle = color
    trailsCtx.beginPath()
    stars.forEach((star) => {
      if (star.visible) {
        trailsCtx.moveTo(star.x, star.y)
        trailsCtx.lineTo(star.prevX, star.prevY)
        mainCtx.moveTo(star.x, star.y)
        mainCtx.lineTo(star.x - star.speedX * 1.6, star.y - star.speedY * 1.6)
      }
    })
    trailsCtx.stroke()
  })
  mainCtx.stroke()

  // Draw sparks
  trailsCtx.lineWidth = Spark.drawWidth
  trailsCtx.lineCap = 'butt'
  COLOR_CODES.forEach((color) => {
    const sparks = Spark.active[color]
    trailsCtx.strokeStyle = color
    trailsCtx.beginPath()
    sparks.forEach((spark) => {
      trailsCtx.moveTo(spark.x, spark.y)
      trailsCtx.lineTo(spark.prevX, spark.prevY)
    })
    trailsCtx.stroke()
  })

  // Render speed bar if visible
  if (speedBarOpacity) {
    const speedBarHeight = 6
    mainCtx.globalAlpha = speedBarOpacity
    mainCtx.fillStyle = COLOR.Blue
    mainCtx.fillRect(
      0,
      height - speedBarHeight,
      width * simSpeed,
      speedBarHeight
    )
    mainCtx.globalAlpha = 1
  }

  trailsCtx.setTransform(1, 0, 0, 1, 0, 0)
  mainCtx.setTransform(1, 0, 0, 1, 0, 0)
}

// Draw colored overlay based on combined brightness of stars (light up the sky!)
// Note: this is applied to the canvas container's background-color, so it's behind the particles
const currentSkyColor = { r: 0, g: 0, b: 0 }
const targetSkyColor = { r: 0, g: 0, b: 0 }
function colorSky(speed) {
  // The maximum r, g, or b value that will be used (255 would represent no maximum)
  const maxSkySaturation = skyLightingSelector() * 15
  // How many stars are required in total to reach maximum sky brightness
  const maxStarCount = 500
  let totalStarCount = 0
  // Initialize sky as black
  targetSkyColor.r = 0
  targetSkyColor.g = 0
  targetSkyColor.b = 0
  // Add each known color to sky, multiplied by particle count of that color. This will put RGB values wildly out of bounds, but we'll scale them back later.
  // Also add up total star count.
  COLOR_CODES.forEach((color) => {
    const tuple = COLOR_TUPLES[color]
    const count = Star.active[color].length
    totalStarCount += count
    targetSkyColor.r += tuple.r * count
    targetSkyColor.g += tuple.g * count
    targetSkyColor.b += tuple.b * count
  })

  // Clamp intensity at 1.0, and map to a custom non-linear curve. This allows few stars to perceivably light up the sky, while more stars continue to increase the brightness but at a lesser rate. This is more inline with humans' non-linear brightness perception.
  const intensity = Math.pow(Math.min(1, totalStarCount / maxStarCount), 0.3)
  // Figure out which color component has the highest value, so we can scale them without affecting the ratios.
  // Prevent 0 from being used, so we don't divide by zero in the next step.
  const maxColorComponent = Math.max(
    1,
    targetSkyColor.r,
    targetSkyColor.g,
    targetSkyColor.b
  )
  // Scale all color components to a max of `maxSkySaturation`, and apply intensity.
  targetSkyColor.r =
    (targetSkyColor.r / maxColorComponent) * maxSkySaturation * intensity
  targetSkyColor.g =
    (targetSkyColor.g / maxColorComponent) * maxSkySaturation * intensity
  targetSkyColor.b =
    (targetSkyColor.b / maxColorComponent) * maxSkySaturation * intensity

  // Animate changes to color to smooth out transitions.
  const colorChange = 10
  currentSkyColor.r +=
    ((targetSkyColor.r - currentSkyColor.r) / colorChange) * speed
  currentSkyColor.g +=
    ((targetSkyColor.g - currentSkyColor.g) / colorChange) * speed
  currentSkyColor.b +=
    ((targetSkyColor.b - currentSkyColor.b) / colorChange) * speed

  appNodes.canvasContainer.style.backgroundColor = `rgb(${
    currentSkyColor.r | 0
  }, ${currentSkyColor.g | 0}, ${currentSkyColor.b | 0})`
}

mainStage.addEventListener('ticker', update)

// Helper used to semi-randomly spread particles over an arc
// Values are flexible - `start` and `arcLength` can be negative, and `randomness` is simply a multiplier for random addition.
function createParticleArc(
  start,
  arcLength,
  count,
  randomness,
  particleFactory
) {
  const angleDelta = arcLength / count
  // Sometimes there is an extra particle at the end, too close to the start. Subtracting half the angleDelta ensures that is skipped.
  // Would be nice to fix this a better way.
  const end = start + arcLength - angleDelta * 0.5

  if (end > start) {
    // Optimization: `angle=angle+angleDelta` vs. angle+=angleDelta
    // V8 deoptimises with let compound assignment
    for (let angle = start; angle < end; angle = angle + angleDelta) {
      particleFactory(angle + Math.random() * angleDelta * randomness)
    }
  } else {
    for (let angle = start; angle > end; angle = angle + angleDelta) {
      particleFactory(angle + Math.random() * angleDelta * randomness)
    }
  }
}

/**
 * Helper used to create a spherical burst of particles.
 *
 * @param  {Number} count               The desired number of stars/particles. This value is a suggestion, and the
 *                                      created burst may have more particles. The current algorithm can't perfectly
 *                                      distribute a specific number of points evenly on a sphere's surface.
 * @param  {Function} particleFactory   Called once per star/particle generated. Passed two arguments:
 *                                        `angle`: The direction of the star/particle.
 *                                        `speed`: A multipler for the particle speed, from 0.0 to 1.0.
 * @param  {Number} startAngle=0        For segmented bursts, you can generate only a partial arc of particles. This
 *                                      allows setting the starting arc angle (radians).
 * @param  {Number} arcLength=TAU       The length of the arc (radians). Defaults to a full circle.
 *
 * @return {void}              Returns nothing; it's up to `particleFactory` to use the given data.
 */
function createBurst(count, particleFactory, startAngle = 0, arcLength = PI_2) {
  // Assuming sphere with surface area of `count`, calculate various
  // properties of said sphere (unit is stars).
  // Radius
  const R = 0.5 * Math.sqrt(count / Math.PI)
  // Circumference
  const C = 2 * R * Math.PI
  // Half Circumference
  const C_HALF = C / 2

  // Make a series of rings, sizing them as if they were spaced evenly
  // along the curved surface of a sphere.
  for (let i = 0; i <= C_HALF; i++) {
    const ringAngle = (i / C_HALF) * PI_HALF
    const ringSize = Math.cos(ringAngle)
    const partsPerFullRing = C * ringSize
    const partsPerArc = partsPerFullRing * (arcLength / PI_2)

    const angleInc = PI_2 / partsPerFullRing
    const angleOffset = Math.random() * angleInc + startAngle
    // Each particle needs a bit of randomness to improve appearance.
    const maxRandomAngleOffset = angleInc * 0.33

    for (let i = 0; i < partsPerArc; i++) {
      const randomAngleOffset = Math.random() * maxRandomAngleOffset
      let angle = angleInc * i + angleOffset + randomAngleOffset
      particleFactory(angle, ringSize)
    }
  }
}

// Various star effects.
// These are designed to be attached to a star's `onDeath` event.

// Crossette breaks star into four same-color pieces which branch in a cross-like shape.
function crossetteEffect(star) {
  const startAngle = Math.random() * PI_HALF
  createParticleArc(startAngle, PI_2, 4, 0.5, (angle) => {
    Star.add(star.x, star.y, star.color, angle, Math.random() * 0.6 + 0.75, 600)
  })
}

// Flower is like a mini shell
function floralEffect(star) {
  const count = 12 + 6 * quality
  createBurst(count, (angle, speedMult) => {
    Star.add(
      star.x,
      star.y,
      star.color,
      angle,
      speedMult * 2.4,
      1000 + Math.random() * 300,
      star.speedX,
      star.speedY
    )
  })
  // Queue burst flash render
  BurstFlash.add(star.x, star.y, 46)
  soundManager.playSound('burstSmall')
}

// Floral burst with willow stars
function fallingLeavesEffect(star) {
  createBurst(7, (angle, speedMult) => {
    const newStar = Star.add(
      star.x,
      star.y,
      INVISIBLE,
      angle,
      speedMult * 2.4,
      2400 + Math.random() * 600,
      star.speedX,
      star.speedY
    )

    newStar.sparkColor = COLOR.Gold
    newStar.sparkFreq = 144 / quality
    newStar.sparkSpeed = 0.28
    newStar.sparkLife = 750
    newStar.sparkLifeVariation = 3.2
  })
  // Queue burst flash render
  BurstFlash.add(star.x, star.y, 46)
  soundManager.playSound('burstSmall')
}

// Crackle pops into a small cloud of golden sparks.
function crackleEffect(star) {
  const count = isHighQuality ? 32 : 16
  createParticleArc(0, PI_2, count, 1.8, (angle) => {
    Spark.add(
      star.x,
      star.y,
      COLOR.Gold,
      angle,
      // apply near cubic falloff to speed (places more particles towards outside)
      Math.pow(Math.random(), 0.45) * 2.4,
      300 + Math.random() * 200
    )
  })
}

/**
 * Shell can be constructed with options:
 *
 * spreadSize:      Size of the burst.
 * starCount: Number of stars to create. This is optional, and will be set to a reasonable quantity for size if omitted.
 * starLife:
 * starLifeVariation:
 * color:
 * glitterColor:
 * glitter: One of: 'light', 'medium', 'heavy', 'streamer', 'willow'
 * pistil:
 * pistilColor:
 * streamers:
 * crossette:
 * floral:
 * crackle:
 */
class Shell {
  constructor(options) {
    Object.assign(this, options)
    this.starLifeVariation = options.starLifeVariation || 0.125
    this.color = options.color || randomColor()
    this.glitterColor = options.glitterColor || this.color

    // Set default starCount if needed, will be based on shell size and scale exponentially, like a sphere's surface area.
    if (!this.starCount) {
      const density = options.starDensity || 1
      const scaledSize = this.spreadSize / 54
      this.starCount = Math.max(6, scaledSize * scaledSize * density)
    }
  }

  launch(position, launchHeight) {
    const width = stageW
    const height = stageH
    // Distance from sides of screen to keep shells.
    const hpad = 60
    // Distance from top of screen to keep shell bursts.
    const vpad = 50
    // Minimum burst height, as a percentage of stage height
    const minHeightPercent = 0.45
    // Minimum burst height in px
    const minHeight = height - height * minHeightPercent

    const launchX = position * (width - hpad * 2) + hpad
    const launchY = height
    const burstY = minHeight - launchHeight * (minHeight - vpad)

    const launchDistance = launchY - burstY
    // Using a custom power curve to approximate Vi needed to reach launchDistance under gravity and air drag.
    // Magic numbers came from testing.
    const launchVelocity = Math.pow(launchDistance * 0.04, 0.64)

    const comet = (this.comet = Star.add(
      launchX,
      launchY,
      typeof this.color === 'string' && this.color !== 'random'
        ? this.color
        : COLOR.White,
      Math.PI,
      launchVelocity * (this.horsetail ? 1.2 : 1),
      // Hang time is derived linearly from Vi; exact number came from testing
      launchVelocity * (this.horsetail ? 100 : 400)
    ))

    // making comet "heavy" limits air drag
    comet.heavy = true
    // comet spark trail
    comet.spinRadius = MyMath.random(0.32, 0.85)
    comet.sparkFreq = 32 / quality
    if (isHighQuality) comet.sparkFreq = 8
    comet.sparkLife = 320
    comet.sparkLifeVariation = 3
    if (this.glitter === 'willow' || this.fallingLeaves) {
      comet.sparkFreq = 20 / quality
      comet.sparkSpeed = 0.5
      comet.sparkLife = 500
    }
    if (this.color === INVISIBLE) {
      comet.sparkColor = COLOR.Gold
    }

    // Randomly make comet "burn out" a bit early.
    // This is disabled for horsetail shells, due to their very short airtime.
    if (Math.random() > 0.4 && !this.horsetail) {
      comet.secondColor = INVISIBLE
      comet.transitionTime = Math.pow(Math.random(), 1.5) * 700 + 500
    }

    comet.onDeath = (comet) => this.burst(comet.x, comet.y)

    soundManager.playSound('lift')
  }

  burst(x, y) {
    // Set burst speed so overall burst grows to set size. This specific formula was derived from testing, and is affected by simulated air drag.
    const speed = this.spreadSize / 96

    let color, onDeath, sparkFreq, sparkSpeed, sparkLife
    let sparkLifeVariation = 0.25
    // Some death effects, like crackle, play a sound, but should only be played once.
    let playedDeathSound = false

    if (this.crossette)
      onDeath = (star) => {
        if (!playedDeathSound) {
          soundManager.playSound('crackleSmall')
          playedDeathSound = true
        }
        crossetteEffect(star)
      }
    if (this.crackle)
      onDeath = (star) => {
        if (!playedDeathSound) {
          soundManager.playSound('crackle')
          playedDeathSound = true
        }
        crackleEffect(star)
      }
    if (this.floral) onDeath = floralEffect
    if (this.fallingLeaves) onDeath = fallingLeavesEffect

    if (this.glitter === 'light') {
      sparkFreq = 400
      sparkSpeed = 0.3
      sparkLife = 300
      sparkLifeVariation = 2
    } else if (this.glitter === 'medium') {
      sparkFreq = 200
      sparkSpeed = 0.44
      sparkLife = 700
      sparkLifeVariation = 2
    } else if (this.glitter === 'heavy') {
      sparkFreq = 80
      sparkSpeed = 0.8
      sparkLife = 1400
      sparkLifeVariation = 2
    } else if (this.glitter === 'thick') {
      sparkFreq = 16
      sparkSpeed = isHighQuality ? 1.65 : 1.5
      sparkLife = 1400
      sparkLifeVariation = 3
    } else if (this.glitter === 'streamer') {
      sparkFreq = 32
      sparkSpeed = 1.05
      sparkLife = 620
      sparkLifeVariation = 2
    } else if (this.glitter === 'willow') {
      sparkFreq = 120
      sparkSpeed = 0.34
      sparkLife = 1400
      sparkLifeVariation = 3.8
    }

    // Apply quality to spark count
    sparkFreq = sparkFreq / quality

    // Star factory for primary burst, pistils, and streamers.
    let firstStar = true
    const starFactory = (angle, speedMult) => {
      // For non-horsetail shells, compute an initial vertical speed to add to star burst.
      // The magic number comes from testing what looks best. The ideal is that all shell
      // bursts appear visually centered for the majority of the star life (excl. willows etc.)
      const standardInitialSpeed = this.spreadSize / 1800

      const star = Star.add(
        x,
        y,
        color || randomColor(),
        angle,
        speedMult * speed,
        // add minor variation to star life
        this.starLife + Math.random() * this.starLife * this.starLifeVariation,
        this.horsetail ? this.comet && this.comet.speedX : 0,
        this.horsetail ? this.comet && this.comet.speedY : -standardInitialSpeed
      )

      if (this.secondColor) {
        star.transitionTime = this.starLife * (Math.random() * 0.05 + 0.32)
        star.secondColor = this.secondColor
      }

      if (this.strobe) {
        star.transitionTime = this.starLife * (Math.random() * 0.08 + 0.46)
        star.strobe = true
        // How many milliseconds between switch of strobe state "tick". Note that the strobe pattern
        // is on:off:off, so this is the "on" duration, while the "off" duration is twice as long.
        star.strobeFreq = Math.random() * 20 + 40
        if (this.strobeColor) {
          star.secondColor = this.strobeColor
        }
      }

      star.onDeath = onDeath

      if (this.glitter) {
        star.sparkFreq = sparkFreq
        star.sparkSpeed = sparkSpeed
        star.sparkLife = sparkLife
        star.sparkLifeVariation = sparkLifeVariation
        star.sparkColor = this.glitterColor
        star.sparkTimer = Math.random() * star.sparkFreq
      }
    }

    if (typeof this.color === 'string') {
      if (this.color === 'random') {
        color = null // falsey value creates random color in starFactory
      } else {
        color = this.color
      }

      // Rings have positional randomness, but are rotated randomly
      if (this.ring) {
        const ringStartAngle = Math.random() * Math.PI
        const ringSquash = Math.pow(Math.random(), 2) * 0.85 + 0.15

        createParticleArc(0, PI_2, this.starCount, 0, (angle) => {
          // Create a ring, squashed horizontally
          const initSpeedX = Math.sin(angle) * speed * ringSquash
          const initSpeedY = Math.cos(angle) * speed
          // Rotate ring
          const newSpeed = MyMath.pointDist(0, 0, initSpeedX, initSpeedY)
          const newAngle =
            MyMath.pointAngle(0, 0, initSpeedX, initSpeedY) + ringStartAngle
          const star = Star.add(
            x,
            y,
            color,
            newAngle,
            // apply near cubic falloff to speed (places more particles towards outside)
            newSpeed, //speed,
            // add minor variation to star life
            this.starLife +
              Math.random() * this.starLife * this.starLifeVariation
          )

          if (this.glitter) {
            star.sparkFreq = sparkFreq
            star.sparkSpeed = sparkSpeed
            star.sparkLife = sparkLife
            star.sparkLifeVariation = sparkLifeVariation
            star.sparkColor = this.glitterColor
            star.sparkTimer = Math.random() * star.sparkFreq
          }
        })
      }
      // Normal burst
      else {
        createBurst(this.starCount, starFactory)
      }
    } else if (Array.isArray(this.color)) {
      if (Math.random() < 0.5) {
        const start = Math.random() * Math.PI
        const start2 = start + Math.PI
        const arc = Math.PI
        color = this.color[0]
        // Not creating a full arc automatically reduces star count.
        createBurst(this.starCount, starFactory, start, arc)
        color = this.color[1]
        createBurst(this.starCount, starFactory, start2, arc)
      } else {
        color = this.color[0]
        createBurst(this.starCount / 2, starFactory)
        color = this.color[1]
        createBurst(this.starCount / 2, starFactory)
      }
    } else {
      throw new Error(
        'Invalid shell color. Expected string or array of strings, but got: ' +
          this.color
      )
    }

    if (this.pistil) {
      const innerShell = new Shell({
        spreadSize: this.spreadSize * 0.5,
        starLife: this.starLife * 0.6,
        starLifeVariation: this.starLifeVariation,
        starDensity: 1.4,
        color: this.pistilColor,
        glitter: 'light',
        glitterColor: this.pistilColor === COLOR.Gold ? COLOR.Gold : COLOR.White
      })
      innerShell.burst(x, y)
    }

    if (this.streamers) {
      const innerShell = new Shell({
        spreadSize: this.spreadSize * 0.9,
        starLife: this.starLife * 0.8,
        starLifeVariation: this.starLifeVariation,
        starCount: Math.floor(Math.max(6, this.spreadSize / 45)),
        color: COLOR.White,
        glitter: 'streamer'
      })
      innerShell.burst(x, y)
    }

    // Queue burst flash render
    BurstFlash.add(x, y, this.spreadSize / 4)

    // Play sound, but only for "original" shell, the one that was launched.
    // We don't want multiple sounds from pistil or streamer "sub-shells".
    // This can be detected by the presence of a comet.
    if (this.comet) {
      // Scale explosion sound based on current shell size and selected (max) shell size.
      // Shooting selected shell size will always sound the same no matter the selected size,
      // but when smaller shells are auto-fired, they will sound smaller. It doesn't sound great
      // when a value too small is given though, so instead of basing it on proportions, we just
      // look at the difference in size and map it to a range known to sound good.
      const maxDiff = 2
      const sizeDifferenceFromMaxSize = Math.min(
        maxDiff,
        shellSizeSelector() - this.shellSize
      )
      const soundScale = (1 - sizeDifferenceFromMaxSize / maxDiff) * 0.3 + 0.7
      soundManager.playSound('burst', soundScale)
    }
  }
}

const BurstFlash = {
  active: [],
  _pool: [],

  _new() {
    return {}
  },

  add(x, y, radius) {
    const instance = this._pool.pop() || this._new()

    instance.x = x
    instance.y = y
    instance.radius = radius

    this.active.push(instance)
    return instance
  },

  returnInstance(instance) {
    this._pool.push(instance)
  }
}

// Helper to generate objects for storing active particles.
// Particles are stored in arrays keyed by color (code, not name) for improved rendering performance.
function createParticleCollection() {
  const collection = {}
  COLOR_CODES_W_INVIS.forEach((color) => {
    collection[color] = []
  })
  return collection
}

// Star properties (WIP)
// -----------------------
// transitionTime - how close to end of life that star transition happens

const Star = {
  // Visual properties
  drawWidth: 3,
  airDrag: 0.98,
  airDragHeavy: 0.992,

  // Star particles will be keyed by color
  active: createParticleCollection(),
  _pool: [],

  _new() {
    return {}
  },

  add(x, y, color, angle, speed, life, speedOffX, speedOffY) {
    const instance = this._pool.pop() || this._new()

    instance.visible = true
    instance.heavy = false
    instance.x = x
    instance.y = y
    instance.prevX = x
    instance.prevY = y
    instance.color = color
    instance.speedX = Math.sin(angle) * speed + (speedOffX || 0)
    instance.speedY = Math.cos(angle) * speed + (speedOffY || 0)
    instance.life = life
    instance.fullLife = life
    instance.spinAngle = Math.random() * PI_2
    instance.spinSpeed = 0.8
    instance.spinRadius = 0
    instance.sparkFreq = 0 // ms between spark emissions
    instance.sparkSpeed = 1
    instance.sparkTimer = 0
    instance.sparkColor = color
    instance.sparkLife = 750
    instance.sparkLifeVariation = 0.25
    instance.strobe = false

    this.active[color].push(instance)
    return instance
  },

  // Public method for cleaning up and returning an instance back to the pool.
  returnInstance(instance) {
    // Call onDeath handler if available (and pass it current star instance)
    instance.onDeath && instance.onDeath(instance)
    // Clean up
    instance.onDeath = null
    instance.secondColor = null
    instance.transitionTime = 0
    instance.colorChanged = false
    // Add back to the pool.
    this._pool.push(instance)
  }
}

const Spark = {
  // Visual properties
  drawWidth: 0, // set in `configDidUpdate()`
  airDrag: 0.9,

  // Star particles will be keyed by color
  active: createParticleCollection(),
  _pool: [],

  _new() {
    return {}
  },

  add(x, y, color, angle, speed, life) {
    const instance = this._pool.pop() || this._new()

    instance.x = x
    instance.y = y
    instance.prevX = x
    instance.prevY = y
    instance.color = color
    instance.speedX = Math.sin(angle) * speed
    instance.speedY = Math.cos(angle) * speed
    instance.life = life

    this.active[color].push(instance)
    return instance
  },

  // Public method for cleaning up and returning an instance back to the pool.
  returnInstance(instance) {
    // Add back to the pool.
    this._pool.push(instance)
  }
}

const soundManager = {
  baseURL: 'https://s3-us-west-2.amazonaws.com/s.cdpn.io/329180/',
  ctx: new (window.AudioContext || window.webkitAudioContext)(),
  sources: {
    lift: {
      volume: 1,
      playbackRateMin: 0.85,
      playbackRateMax: 0.95,
      fileNames: ['lift1.mp3', 'lift2.mp3', 'lift3.mp3']
    },
    burst: {
      volume: 1,
      playbackRateMin: 0.8,
      playbackRateMax: 0.9,
      fileNames: ['burst1.mp3', 'burst2.mp3']
    },
    burstSmall: {
      volume: 0.25,
      playbackRateMin: 0.8,
      playbackRateMax: 1,
      fileNames: ['burst-sm-1.mp3', 'burst-sm-2.mp3']
    },
    crackle: {
      volume: 0.2,
      playbackRateMin: 1,
      playbackRateMax: 1,
      fileNames: ['crackle1.mp3']
    },
    crackleSmall: {
      volume: 0.3,
      playbackRateMin: 1,
      playbackRateMax: 1,
      fileNames: ['crackle-sm-1.mp3']
    }
  },

  preload() {
    const allFilePromises = []

    function checkStatus(response) {
      if (response.status >= 200 && response.status < 300) {
        return response
      }
      const customError = new Error(response.statusText)
      customError.response = response
      throw customError
    }

    const types = Object.keys(this.sources)
    types.forEach((type) => {
      const source = this.sources[type]
      const { fileNames } = source
      const filePromises = []
      fileNames.forEach((fileName) => {
        const fileURL = this.baseURL + fileName
        // Promise will resolve with decoded audio buffer.
        const promise = fetch(fileURL)
          .then(checkStatus)
          .then((response) => response.arrayBuffer())
          .then(
            (data) =>
              new Promise((resolve) => {
                this.ctx.decodeAudioData(data, resolve)
              })
          )

        filePromises.push(promise)
        allFilePromises.push(promise)
      })

      Promise.all(filePromises).then((buffers) => {
        source.buffers = buffers
      })
    })

    return Promise.all(allFilePromises)
  },

  pauseAll() {
    this.ctx.suspend()
  },

  resumeAll() {
    // Play a sound with no volume for iOS. This 'unlocks' the audio context when the user first enables sound.
    this.playSound('lift', 0)
    // Chrome mobile requires interaction before starting audio context.
    // The sound toggle button is triggered on 'touchstart', which doesn't seem to count as a full
    // interaction to Chrome. I guess it needs a click? At any rate if the first thing the user does
    // is enable audio, it doesn't work. Using a setTimeout allows the first interaction to be registered.
    // Perhaps a better solution is to track whether the user has interacted, and if not but they try enabling
    // sound, show a tooltip that they should tap again to enable sound.
    setTimeout(() => {
      this.ctx.resume()
    }, 250)
  },

  // Private property used to throttle small burst sounds.
  _lastSmallBurstTime: 0,

  /**
   * Play a sound of `type`. Will randomly pick a file associated with type, and play it at the specified volume
   * and play speed, with a bit of random variance in play speed. This is all based on `sources` config.
   *
   * @param  {string} type - The type of sound to play.
   * @param  {?number} scale=1 - Value between 0 and 1 (values outside range will be clamped). Scales less than one
   *                             descrease volume and increase playback speed. This is because large explosions are
   *                             louder, deeper, and reverberate longer than small explosions.
   *                             Note that a scale of 0 will mute the sound.
   */
  playSound(type, scale = 1) {
    // Ensure `scale` is within valid range.
    scale = MyMath.clamp(scale, 0, 1)

    // Disallow starting new sounds if sound is disabled, app is running in slow motion, or paused.
    // Slow motion check has some wiggle room in case user doesn't finish dragging the speed bar
    // *all* the way back.
    if (!canPlaySoundSelector() || simSpeed < 0.95) {
      return
    }

    // Throttle small bursts, since floral/falling leaves shells have a lot of them.
    if (type === 'burstSmall') {
      const now = Date.now()
      if (now - this._lastSmallBurstTime < 20) {
        return
      }
      this._lastSmallBurstTime = now
    }

    const source = this.sources[type]

    if (!source) {
      throw new Error(`Sound of type "${type}" doesn't exist.`)
    }

    const initialVolume = source.volume
    const initialPlaybackRate = MyMath.random(
      source.playbackRateMin,
      source.playbackRateMax
    )

    // Volume descreases with scale.
    const scaledVolume = initialVolume * scale
    // Playback rate increases with scale. For this, we map the scale of 0-1 to a scale of 2-1.
    // So at a scale of 1, sound plays normally, but as scale approaches 0 speed approaches double.
    const scaledPlaybackRate = initialPlaybackRate * (2 - scale)

    const gainNode = this.ctx.createGain()
    gainNode.gain.value = scaledVolume

    const buffer = MyMath.randomChoice(source.buffers)
    const bufferSource = this.ctx.createBufferSource()
    bufferSource.playbackRate.value = scaledPlaybackRate
    bufferSource.buffer = buffer
    bufferSource.connect(gainNode)
    gainNode.connect(this.ctx.destination)
    bufferSource.start(0)
  }
}

// Kick things off.

function setLoadingStatus(status) {
  document.querySelector('.loading-init__status').textContent = status
}

// CodePen profile header doesn't need audio, just initialize.
if (IS_HEADER) {
  init()
} else {
  // Allow status to render, then preload assets and start app.
  // setLoadingStatus('献给你爱的TA ❤')
  setTimeout(() => {
    soundManager.preload().then(init, (reason) => {
      // Codepen preview doesn't like to load the audio, so just init to fix the preview for now.
      init()
      // setLoadingStatus('Error Loading Audio');
      return Promise.reject(reason)
    })
  }, 0)
}
